Repository: microsoft/terminal (language: cpp)

Issue:
Auto generate profile from .ssh/config
<!-- 
🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨

I ACKNOWLEDGE THE FOLLOWING BEFORE PROCEEDING:
1. If I delete this entire template and go my own path, the core team may close my issue without further explanation or engagement.
2. If I list multiple bugs/concerns in this one issue, the core team may close my issue without further explanation or engagement.
3. If I write an issue that has many duplicates, the core team may close my issue without further explanation or engagement (and without necessarily spending time to find the exact duplicate ID number).
4. If I leave the title incomplete when filing the issue, the core team may close my issue without further explanation or engagement.
5. If I file something completely blank in the body, the core team may close my issue without further explanation or engagement.

All good? Then proceed!
-->

# Description of the new feature/enhancement

<!-- 
A clear and concise description of what the problem is that the new feature would solve.
Describe why and how a user would use this new functionality (if applicable).
-->

Dynamic profiles like "Windows.Terminal.Wsl", "Windows.Terminal.Azure", "Windows.Terminal.PowershellCore"

eg: 

If `.ssh/config` like this:

```
Host 251
    HostName 192.168.29.251
    Port 22
    User hanxi
Host 252
    HostName 192.168.29.252
    Port 22
    User hanxi
```

Then generate profile like this:

```json
  {
      "guid": "{a060905f-d089-43d9-9422-cd748e7f0230}",
      "name": "ssh251",
      "commandline": "powershell.exe ssh 251",
      "closeOnExit" : true
  }
  {
      "guid": "{a060905f-d089-43d9-9422-cd748e7f0231}",
      "name": "ssh252",
      "commandline": "powershell.exe ssh 252",
      "closeOnExit" : true
  }
```

# Proposed technical implementation details (optional)

<!-- 
A clear and concise description of what you want to happen.
-->

Also support `disabledProfileSources` setting.

```json
"disabledProfileSources": ["Windows.Terminal.Wsl", "Windows.Terminal.Azure", "Windows.Terminal.PowershellCore", "Windows.Terminal.SSH"]
```

If OpenSSH not installed, then auto disable "Windows.Terminal.SSH" .


Discussion / hints:
I don't hate this. What I'm most worried about is the full path to `.ssh/config` - how consistent is that across various different methods of installing `ssh`? I'm worried that there's different paths for the built-in ssh, vs the version you'd get from the win32-openssh github repo, vs the one you'd install from winget, vs the one from scoop, vs chocolatey, vs git bash.... and now the logic gets way funkier. 

That being said, I could be entirely wrong and it might just be a static path. That would certainly make this easier.
@zadjii-msft I test this ssh version use `.ssh/config` from `%HOMEPATH%/.ssh/config`

- [x] built-in ssh
- [x] win32-openssh github repo
- [ ] winget no openssh
- [ ] scoop install failed
- [ ] chocolatey install failed
- [x] git bash

So I think `.ssh/config` is a static path.
Do we still accept new profile generators? I thought we have moved pass that period. That being said I don't hate the idea, either. Also I'd love to see #7774 landed.
I'm thinking that this is one of those cases where a proto-extension actually _won't_ work super well. They've got their config in this file already, but then would need to add another service to watch that file for changes. That's the only way I could imagine changes to that file being automatically picked up and turned into new profiles. Plus, I really doubt `ssh` as a project would ship anything like a proto-extension.

So this one seems simple enough, and with `win32-openssh` being shipped in-box, it's first-party _enough_. I'll bring it up in team discussion Monday, but this seems like a good idea to me.   
@zadjii-msft I'd argue Visual Studio is also first- party. But generally I see what you mean. 
@skyline75489 VS definitely _is_ first party, which is why we're gonna try and get that PR merged this month 😉 
This will be an excellent first-party extension. I'm hesitant to do it as a built-in generator (since I don't think we need *more* of those), but it can come in soon after.
@wsluser i cannot determine how any of the things you said are related to this feature request. Can you explain?
Got it. Nothing to do with this discussion -- no environment variable, nothing about CMD/PSCore/PowerShell, no path to the SSH executable. I'm gonna collapse these comments as resolved-off-topic
Currently I mainly use ssh through WSL to connect me to servers. I use custom profiles for that.
It would be interesting to be able to choose if we want to generate profile for ssh from windows or from WSL.
I also find myself adding SSH configs to my terminal profiles regularly. However, it could be nice to have a way to setup an includelist/excludelist since I have some configs that just alias some server addresses to a nicer name, but I would have an unusually long profile list if I couldn't opt-out/opt-in which profiles are displayed. That sort of mechanism may be useful for other profile generators as well in case people want to have even finer-grained control over them. I also have some ssh configs that "chain" options (ex. a profile that applies to all 10.0.1.* servers), so I wouldn't necessarily want those kind of configs to show up and a include/exclude list could help with that. Another alternative would be only adding profiles for ssh configs that have a `HostName` field.

Another thought on this: Since people may have a number of different openssh installs/install locations (as mentioned above), would this just default to using the openssh version in their PATH? That would make sense, though I know that some people may use the git bash/git-bundled ssh along with its agent, which reads from the same `.ssh\config` as the built-in OpenSSH, but don't share agent-held keys without some extra configuration since each of these SSH installs has its own agent. Those may want to point their profiles to the git-bundled ssh executable if they use the git-bundled ssh's agent. I'm thinking we could make those profiles take an alternate ssh executable path as the `commandline` config option, or we can have the feature stick to relying on whatever is in the PATH environment variable and document that behavior somewhere. It's easy enough to choose which ssh version you want by placing it higher up in the PATH list and it should work out of the box for most people since most default Windows installs will have some SSH executable in their path.
I found a plugin in vscode for ssh. It use `.ssh/config` file.

https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh

![image](https://user-images.githubusercontent.com/1185757/115527146-5eef5e00-a2c3-11eb-8810-ab4b1915a18e.png)

>       "commandline": "powershell.exe ssh 251",

For anyone trying this with MS PS, the cmd is `pwsh -c ssh <host>`

This feature would be nice to have.
Plus 1 on that one. I would very much appreciate such feature! I use vscode and terminal pretty much every day for remote development etc. VSCode already uses the .ssh/config. Terminal doesn’t. Which means I need to maintain two different config files. Kinda inconvenient. How is the progress going btw? 
> How is the progress going btw

There's no one currently working on this. If you'd like to contribute, I think we'd welcome the contribution. 

There may be some continued debate as to whether this should be bundled with the application or supported as a "3rd party" extension. At the moment we don't really have a LoS on 3rd party extensions like this though. I'm in the camp of "probably okay to ship with the Terminal for now". 

Fix patch:
diff --git a/.github/actions/spelling/allow/names.txt b/.github/actions/spelling/allow/names.txt
index 1c6ef9a373c..56ae1aa03ac 100644
--- a/.github/actions/spelling/allow/names.txt
+++ b/.github/actions/spelling/allow/names.txt
@@ -77,6 +77,7 @@ sonpham
 stakx
 talo
 thereses
+Thysell
 Walisch
 WDX
 Wellons
diff --git a/src/cascadia/TerminalSettingsModel/CascadiaSettingsSerialization.cpp b/src/cascadia/TerminalSettingsModel/CascadiaSettingsSerialization.cpp
index da175280cb0..39124e2f0d5 100644
--- a/src/cascadia/TerminalSettingsModel/CascadiaSettingsSerialization.cpp
+++ b/src/cascadia/TerminalSettingsModel/CascadiaSettingsSerialization.cpp
@@ -13,6 +13,9 @@
 #include "PowershellCoreProfileGenerator.h"
 #include "VisualStudioGenerator.h"
 #include "WslDistroGenerator.h"
+#if TIL_FEATURE_DYNAMICSSHPROFILES_ENABLED
+#include "SshHostGenerator.h"
+#endif
 
 // The following files are generated at build time into the "Generated Files" directory.
 // defaults(-universal).h is a file containing the default json settings in a std::string_view.
@@ -148,6 +151,9 @@ void SettingsLoader::GenerateProfiles()
     _executeGenerator(WslDistroGenerator{});
     _executeGenerator(AzureCloudShellGenerator{});
     _executeGenerator(VisualStudioGenerator{});
+#if TIL_FEATURE_DYNAMICSSHPROFILES_ENABLED
+    _executeGenerator(SshHostGenerator{});
+#endif
 }
 
 // A new settings.json gets a special treatment:
diff --git a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj
index a1aaf301ce8..6231c3664a9 100644
--- a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj
+++ b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj
@@ -92,6 +92,7 @@
     <ClInclude Include="VsDevShellGenerator.h" />
     <ClInclude Include="VsSetupConfiguration.h" />
     <ClInclude Include="WslDistroGenerator.h" />
+    <ClInclude Include="SshHostGenerator.h" />
     <ClInclude Include="ModelSerializationHelpers.h" />
   </ItemGroup>
   <!-- ========================= Cpp Files ======================== -->
@@ -166,6 +167,7 @@
     <ClCompile Include="VsDevShellGenerator.cpp" />
     <ClCompile Include="VsSetupConfiguration.cpp" />
     <ClCompile Include="WslDistroGenerator.cpp" />
+    <ClCompile Include="SshHostGenerator.cpp" />
     <!-- You _NEED_ to include this file and the jsoncpp IncludePath (below) if
     you want to use jsoncpp -->
     <ClCompile Include="$(OpenConsoleDir)\dep\jsoncpp\jsoncpp.cpp">
diff --git a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj.filters b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj.filters
index fb21fccdc31..3a4aab82329 100644
--- a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj.filters
+++ b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj.filters
@@ -18,6 +18,9 @@
     <ClCompile Include="WslDistroGenerator.cpp">
       <Filter>profileGeneration</Filter>
     </ClCompile>
+    <ClCompile Include="SshHostGenerator.cpp">
+      <Filter>profileGeneration</Filter>
+    </ClCompile>
     <ClCompile Include="CascadiaSettings.cpp" />
     <ClCompile Include="CascadiaSettingsSerialization.cpp" />
     <ClCompile Include="GlobalAppSettings.cpp" />
@@ -61,6 +64,9 @@
     <ClInclude Include="WslDistroGenerator.h">
       <Filter>profileGeneration</Filter>
     </ClInclude>
+    <ClInclude Include="SshHostGenerator.h">
+      <Filter>profileGeneration</Filter>
+    </ClInclude>
     <ClInclude Include="CascadiaSettings.h" />
     <ClInclude Include="GlobalAppSettings.h" />
     <ClInclude Include="TerminalSettingsSerializationHelpers.h" />
diff --git a/src/cascadia/TerminalSettingsModel/SshHostGenerator.cpp b/src/cascadia/TerminalSettingsModel/SshHostGenerator.cpp
new file mode 100644
index 00000000000..30dc19039f0
--- /dev/null
+++ b/src/cascadia/TerminalSettingsModel/SshHostGenerator.cpp
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+#include "pch.h"
+
+#include "SshHostGenerator.h"
+#include "../../inc/DefaultSettings.h"
+
+#include "DynamicProfileUtils.h"
+
+static constexpr std::wstring_view SshHostGeneratorNamespace{ L"Windows.Terminal.SSH" };
+
+static constexpr std::wstring_view PROFILE_TITLE_PREFIX = L"SSH - ";
+static constexpr std::wstring_view PROFILE_ICON_PATH = L"ms-appx:///ProfileIcons/{550ce7b8-d500-50ad-8a1a-c400c3262db3}.png";
+
+// OpenSSH is installed under System32 when installed via Optional Features
+static constexpr std::wstring_view SSH_EXE_PATH1 = L"%SystemRoot%\\System32\\OpenSSH\\ssh.exe";
+
+// OpenSSH (x86/x64) is installed under Program Files when installed via MSI
+static constexpr std::wstring_view SSH_EXE_PATH2 = L"%ProgramFiles%\\OpenSSH\\ssh.exe";
+
+// OpenSSH (x86) is installed under Program Files x86 when installed via MSI on x64 machine
+static constexpr std::wstring_view SSH_EXE_PATH3 = L"%ProgramFiles(x86)%\\OpenSSH\\ssh.exe";
+
+static constexpr std::wstring_view SSH_SYSTEM_CONFIG_PATH = L"%ProgramData%\\ssh\\ssh_config";
+static constexpr std::wstring_view SSH_USER_CONFIG_PATH = L"%UserProfile%\\.ssh\\config";
+
+static constexpr std::wstring_view SSH_CONFIG_HOST_KEY{ L"Host" };
+static constexpr std::wstring_view SSH_CONFIG_HOSTNAME_KEY{ L"HostName" };
+
+using namespace ::Microsoft::Terminal::Settings::Model;
+using namespace winrt::Microsoft::Terminal::Settings::Model;
+
+/*static*/ const std::wregex SshHostGenerator::_configKeyValueRegex{ LR"(^\s*(\w+)\s+([^\s]+.*[^\s])\s*$)" };
+
+/*static*/ std::wstring_view SshHostGenerator::_getProfileName(const std::wstring_view& hostName) noexcept
+{
+    return std::wstring_view{ L"" + PROFILE_TITLE_PREFIX + hostName };
+}
+
+/*static*/ std::wstring_view SshHostGenerator::_getProfileIconPath() noexcept
+{
+    return PROFILE_ICON_PATH;
+}
+
+/*static*/ std::wstring_view SshHostGenerator::_getProfileCommandLine(const std::wstring_view& sshExePath, const std::wstring_view& hostName) noexcept
+{
+    return std::wstring_view{ L"\"" + sshExePath + L"\" " + hostName };
+}
+
+/*static*/ bool SshHostGenerator::_tryFindSshExePath(std::wstring& sshExePath) noexcept
+{
+    try
+    {
+        for (const auto& path : { SSH_EXE_PATH1, SSH_EXE_PATH2, SSH_EXE_PATH3 })
+        {
+            if (std::filesystem::exists(wil::ExpandEnvironmentStringsW<std::wstring>(path.data())))
+            {
+                sshExePath = path;
+                return true;
+            }
+        }
+    }
+    CATCH_LOG();
+
+    return false;
+}
+
+/*static*/ bool SshHostGenerator::_tryParseConfigKeyValue(const std::wstring_view& line, std::wstring& key, std::wstring& value) noexcept
+{
+    try
+    {
+        if (!line.empty() && !line.starts_with(L"#"))
+        {
+            std::wstring input{ line };
+            std::wsmatch match;
+            if (std::regex_search(input, match, SshHostGenerator::_configKeyValueRegex))
+            {
+                key = match[1];
+                value = match[2];
+                return true;
+            }
+        }
+    }
+    CATCH_LOG();
+
+    return false;
+}
+
+/*static*/ void SshHostGenerator::_getHostNamesFromConfigFile(const std::wstring_view& configPath, std::vector<std::wstring>& hostNames) noexcept
+{
+    try
+    {
+        const std::filesystem::path resolvedConfigPath{ wil::ExpandEnvironmentStringsW<std::wstring>(configPath.data()) };
+        if (std::filesystem::exists(resolvedConfigPath))
+        {
+            std::wifstream inputStream(resolvedConfigPath);
+
+            std::wstring line;
+            std::wstring key;
+            std::wstring value;
+
+            std::wstring lastHost;
+
+            while (std::getline(inputStream, line))
+            {
+                if (_tryParseConfigKeyValue(line, key, value))
+                {
+                    if (til::equals_insensitive_ascii(key, SSH_CONFIG_HOST_KEY))
+                    {
+                        // Save potential Host value for later
+                        lastHost = value;
+                    }
+                    else if (til::equals_insensitive_ascii(key, SSH_CONFIG_HOSTNAME_KEY))
+                    {
+                        // HostName was specified
+                        if (!lastHost.empty())
+                        {
+                            hostNames.emplace_back(lastHost);
+                            lastHost = L"";
+                        }
+                    }
+                }
+            }
+        }
+    }
+    CATCH_LOG();
+}
+
+std::wstring_view SshHostGenerator::GetNamespace() const noexcept
+{
+    return SshHostGeneratorNamespace;
+}
+
+// Method Description:
+// - Generate a list of profiles for each detected OpenSSH host.
+// Arguments:
+// - <none>
+// Return Value:
+// - <A list of SSH host profiles.>
+void SshHostGenerator::GenerateProfiles(std::vector<winrt::com_ptr<implementation::Profile>>& profiles) const
+{
+    std::wstring sshExePath;
+    if (_tryFindSshExePath(sshExePath))
+    {
+        std::vector<std::wstring> hostNames;
+
+        _getHostNamesFromConfigFile(SSH_SYSTEM_CONFIG_PATH, hostNames);
+        _getHostNamesFromConfigFile(SSH_USER_CONFIG_PATH, hostNames);
+
+        for (const auto& hostName : hostNames)
+        {
+            const auto profile{ CreateDynamicProfile(_getProfileName(hostName)) };
+
+            profile->Commandline(winrt::hstring{ _getProfileCommandLine(sshExePath, hostName) });
+            profile->Icon(winrt::hstring{ _getProfileIconPath() });
+
+            profiles.emplace_back(profile);
+        }
+    }
+}
diff --git a/src/cascadia/TerminalSettingsModel/SshHostGenerator.h b/src/cascadia/TerminalSettingsModel/SshHostGenerator.h
new file mode 100644
index 00000000000..a355b0c4302
--- /dev/null
+++ b/src/cascadia/TerminalSettingsModel/SshHostGenerator.h
@@ -0,0 +1,40 @@
+/*++
+Copyright (c) Microsoft Corporation
+Licensed under the MIT license.
+
+Module Name:
+- SshHostGenerator
+
+Abstract:
+- This is the dynamic profile generator for SSH connections. Enumerates all the
+  SSH hosts to create profiles for them.
+
+Author(s):
+- Jon Thysell - September 2022
+
+--*/
+
+#pragma once
+
+#include "IDynamicProfileGenerator.h"
+
+namespace winrt::Microsoft::Terminal::Settings::Model
+{
+    class SshHostGenerator final : public IDynamicProfileGenerator
+    {
+    public:
+        std::wstring_view GetNamespace() const noexcept override;
+        void GenerateProfiles(std::vector<winrt::com_ptr<implementation::Profile>>& profiles) const override;
+
+    private:
+        static const std::wregex _configKeyValueRegex;
+
+        static std::wstring_view _getProfileName(const std::wstring_view& hostName) noexcept;
+        static std::wstring_view _getProfileIconPath() noexcept;
+        static std::wstring_view _getProfileCommandLine(const std::wstring_view& sshExePath, const std::wstring_view& hostName) noexcept;
+
+        static bool _tryFindSshExePath(std::wstring& sshExePath) noexcept;
+        static bool _tryParseConfigKeyValue(const std::wstring_view& line, std::wstring& key, std::wstring& value) noexcept;
+        static void _getHostNamesFromConfigFile(const std::wstring_view& configPath, std::vector<std::wstring>& hostNames) noexcept;
+    };
+};
diff --git a/src/features.xml b/src/features.xml
index 9d3c36cf7d3..e26c217d3bf 100644
--- a/src/features.xml
+++ b/src/features.xml
@@ -138,4 +138,11 @@
         </alwaysEnabledBrandingTokens>
     </feature>
 
+    <feature>
+        <name>Feature_DynamicSSHProfiles</name>
+        <description>Enables the dynamic profile generator for OpenSSH config files</description>
+        <id>9031</id>
+        <stage>AlwaysDisabled</stage>
+    </feature>
+
 </featureStaging>


Test patch: