Repository: ansible-collections/community.general

Issue:
ssh_config: Remove dependency on StormSSH
### Summary

StormSSH has not had a release on [pypi](https://pypi.org/project/stormssh/#history) since 2018. The [github repo](https://github.com/emre/storm) is marked as "Public Archive" and says "This repository has been archived by the owner on Dec 21, 2022. It is now read-only."

Since [ssh_config only uses the config parser](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/ssh_config.py#L164), which is a small part of StormSSH ([the config parser](https://github.com/emre/storm/blob/master/storm/parsers/ssh_config_parser.py)), would it be possible to include the ConfigParser code in this project?

### Issue Type

Feature Idea

### Component Name

ssh_config

### Additional Information

_No response_

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/ssh_config.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/ssh_config.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @Akasurde @gaqzi
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Since stormssh is MIT licensed (https://github.com/emre/storm/blob/master/LICENSE) this should be possible.

Resolving patch:
diff --git a/plugins/module_utils/_stormssh.py b/plugins/module_utils/_stormssh.py
new file mode 100644
--- /dev/null
+++ b/plugins/module_utils/_stormssh.py
@@ -0,0 +1,258 @@
+# -*- coding: utf-8 -*-
+# This code is part of Ansible, but is an independent component.
+# This particular file snippet, and this file snippet only, is based on
+# the config parser from here: https://github.com/emre/storm/blob/master/storm/parsers/ssh_config_parser.py
+# Copyright (C) <2013> <Emre Yilmaz>
+# SPDX-License-Identifier: MIT
+
+from __future__ import (absolute_import, division, print_function)
+import os
+import re
+import traceback
+from operator import itemgetter
+
+__metaclass__ = type
+
+try:
+    from paramiko.config import SSHConfig
+except ImportError:
+    SSHConfig = object
+    HAS_PARAMIKO = False
+    PARAMIKO_IMPORT_ERROR = traceback.format_exc()
+else:
+    HAS_PARAMIKO = True
+    PARAMIKO_IMPORT_ERROR = None
+
+
+class StormConfig(SSHConfig):
+    def parse(self, file_obj):
+        """
+        Read an OpenSSH config from the given file object.
+        @param file_obj: a file-like object to read the config file from
+        @type file_obj: file
+        """
+        order = 1
+        host = {"host": ['*'], "config": {}, }
+        for line in file_obj:
+            line = line.rstrip('\n').lstrip()
+            if line == '':
+                self._config.append({
+                    'type': 'empty_line',
+                    'value': line,
+                    'host': '',
+                    'order': order,
+                })
+                order += 1
+                continue
+
+            if line.startswith('#'):
+                self._config.append({
+                    'type': 'comment',
+                    'value': line,
+                    'host': '',
+                    'order': order,
+                })
+                order += 1
+                continue
+
+            if '=' in line:
+                # Ensure ProxyCommand gets properly split
+                if line.lower().strip().startswith('proxycommand'):
+                    proxy_re = re.compile(r"^(proxycommand)\s*=*\s*(.*)", re.I)
+                    match = proxy_re.match(line)
+                    key, value = match.group(1).lower(), match.group(2)
+                else:
+                    key, value = line.split('=', 1)
+                    key = key.strip().lower()
+            else:
+                # find first whitespace, and split there
+                i = 0
+                while (i < len(line)) and not line[i].isspace():
+                    i += 1
+                if i == len(line):
+                    raise Exception('Unparsable line: %r' % line)
+                key = line[:i].lower()
+                value = line[i:].lstrip()
+            if key == 'host':
+                self._config.append(host)
+                value = value.split()
+                host = {
+                    key: value,
+                    'config': {},
+                    'type': 'entry',
+                    'order': order
+                }
+                order += 1
+            elif key in ['identityfile', 'localforward', 'remoteforward']:
+                if key in host['config']:
+                    host['config'][key].append(value)
+                else:
+                    host['config'][key] = [value]
+            elif key not in host['config']:
+                host['config'].update({key: value})
+        self._config.append(host)
+
+
+class ConfigParser(object):
+    """
+    Config parser for ~/.ssh/config files.
+    """
+
+    def __init__(self, ssh_config_file=None):
+        if not ssh_config_file:
+            ssh_config_file = self.get_default_ssh_config_file()
+
+        self.defaults = {}
+
+        self.ssh_config_file = ssh_config_file
+
+        if not os.path.exists(self.ssh_config_file):
+            if not os.path.exists(os.path.dirname(self.ssh_config_file)):
+                os.makedirs(os.path.dirname(self.ssh_config_file))
+            open(self.ssh_config_file, 'w+').close()
+            os.chmod(self.ssh_config_file, 0o600)
+
+        self.config_data = []
+
+    def get_default_ssh_config_file(self):
+        return os.path.expanduser("~/.ssh/config")
+
+    def load(self):
+        config = StormConfig()
+
+        with open(self.ssh_config_file) as fd:
+            config.parse(fd)
+
+        for entry in config.__dict__.get("_config"):
+            if entry.get("host") == ["*"]:
+                self.defaults.update(entry.get("config"))
+
+            if entry.get("type") in ["comment", "empty_line"]:
+                self.config_data.append(entry)
+                continue
+
+            host_item = {
+                'host': entry["host"][0],
+                'options': entry.get("config"),
+                'type': 'entry',
+                'order': entry.get("order", 0),
+            }
+
+            if len(entry["host"]) > 1:
+                host_item.update({
+                    'host': " ".join(entry["host"]),
+                })
+            # minor bug in paramiko.SSHConfig that duplicates
+            # "Host *" entries.
+            if entry.get("config") and len(entry.get("config")) > 0:
+                self.config_data.append(host_item)
+
+        return self.config_data
+
+    def add_host(self, host, options):
+        self.config_data.append({
+            'host': host,
+            'options': options,
+            'order': self.get_last_index(),
+        })
+
+        return self
+
+    def update_host(self, host, options, use_regex=False):
+        for index, host_entry in enumerate(self.config_data):
+            if host_entry.get("host") == host or \
+                    (use_regex and re.match(host, host_entry.get("host"))):
+
+                if 'deleted_fields' in options:
+                    deleted_fields = options.pop("deleted_fields")
+                    for deleted_field in deleted_fields:
+                        del self.config_data[index]["options"][deleted_field]
+
+                self.config_data[index]["options"].update(options)
+
+        return self
+
+    def search_host(self, search_string):
+        results = []
+        for host_entry in self.config_data:
+            if host_entry.get("type") != 'entry':
+                continue
+            if host_entry.get("host") == "*":
+                continue
+
+            searchable_information = host_entry.get("host")
+            for key, value in host_entry.get("options").items():
+                if isinstance(value, list):
+                    value = " ".join(value)
+                if isinstance(value, int):
+                    value = str(value)
+
+                searchable_information += " " + value
+
+            if search_string in searchable_information:
+                results.append(host_entry)
+
+        return results
+
+    def delete_host(self, host):
+        found = 0
+        for index, host_entry in enumerate(self.config_data):
+            if host_entry.get("host") == host:
+                del self.config_data[index]
+                found += 1
+
+        if found == 0:
+            raise ValueError('No host found')
+        return self
+
+    def delete_all_hosts(self):
+        self.config_data = []
+        self.write_to_ssh_config()
+
+        return self
+
+    def dump(self):
+        if len(self.config_data) < 1:
+            return
+
+        file_content = ""
+        self.config_data = sorted(self.config_data, key=itemgetter("order"))
+
+        for host_item in self.config_data:
+            if host_item.get("type") in ['comment', 'empty_line']:
+                file_content += host_item.get("value") + "\n"
+                continue
+            host_item_content = "Host {0}\n".format(host_item.get("host"))
+            for key, value in host_item.get("options").items():
+                if isinstance(value, list):
+                    sub_content = ""
+                    for value_ in value:
+                        sub_content += "    {0} {1}\n".format(
+                            key, value_
+                        )
+                    host_item_content += sub_content
+                else:
+                    host_item_content += "    {0} {1}\n".format(
+                        key, value
+                    )
+            file_content += host_item_content
+
+        return file_content
+
+    def write_to_ssh_config(self):
+        with open(self.ssh_config_file, 'w+') as f:
+            data = self.dump()
+            if data:
+                f.write(data)
+        return self
+
+    def get_last_index(self):
+        last_index = 0
+        indexes = []
+        for item in self.config_data:
+            if item.get("order"):
+                indexes.append(item.get("order"))
+        if len(indexes) > 0:
+            last_index = max(indexes)
+
+        return last_index
diff --git a/plugins/modules/ssh_config.py b/plugins/modules/ssh_config.py
--- a/plugins/modules/ssh_config.py
+++ b/plugins/modules/ssh_config.py
@@ -8,6 +8,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from __future__ import absolute_import, division, print_function
+
 __metaclass__ = type
 
 DOCUMENTATION = r'''
@@ -101,7 +102,7 @@
     type: str
     version_added: 6.1.0
 requirements:
-- StormSSH
+- paramiko
 '''
 
 EXAMPLES = r'''
@@ -160,26 +161,20 @@
 '''
 
 import os
-import traceback
 
 from copy import deepcopy
 
-STORM_IMP_ERR = None
-try:
-    from storm.parsers.ssh_config_parser import ConfigParser
-    HAS_STORM = True
-except ImportError:
-    HAS_STORM = False
-    STORM_IMP_ERR = traceback.format_exc()
-
 from ansible.module_utils.basic import AnsibleModule, missing_required_lib
 from ansible.module_utils.common.text.converters import to_native
+from ansible_collections.community.general.plugins.module_utils._stormssh import ConfigParser, HAS_PARAMIKO, PARAMIKO_IMPORT_ERROR
 from ansible_collections.community.general.plugins.module_utils.ssh import determine_config_file
 
 
-class SSHConfig():
+class SSHConfig(object):
     def __init__(self, module):
         self.module = module
+        if not HAS_PARAMIKO:
+            module.fail_json(msg=missing_required_lib('PARAMIKO'), exception=PARAMIKO_IMPORT_ERROR)
         self.params = module.params
         self.user = self.params.get('user')
         self.group = self.params.get('group') or self.user
@@ -265,7 +260,8 @@ def ensure_state(self):
             try:
                 self.config.write_to_ssh_config()
             except PermissionError as perm_exec:
-                self.module.fail_json(msg="Failed to write to %s due to permission issue: %s" % (self.config_file, to_native(perm_exec)))
+                self.module.fail_json(
+                    msg="Failed to write to %s due to permission issue: %s" % (self.config_file, to_native(perm_exec)))
             # Make sure we set the permission
             perm_mode = '0600'
             if self.config_file == '/etc/ssh/ssh_config':
@@ -327,10 +323,6 @@ def main():
         ],
     )
 
-    if not HAS_STORM:
-        module.fail_json(changed=False, msg=missing_required_lib("stormssh"),
-                         exception=STORM_IMP_ERR)
-
     ssh_config_obj = SSHConfig(module)
     ssh_config_obj.ensure_state()
 


Test patch:
diff --git a/tests/integration/targets/ssh_config/tasks/main.yml b/tests/integration/targets/ssh_config/tasks/main.yml
--- a/tests/integration/targets/ssh_config/tasks/main.yml
+++ b/tests/integration/targets/ssh_config/tasks/main.yml
@@ -6,7 +6,6 @@
 - name: Install required libs
   pip:
     name:
-      - stormssh
       - 'paramiko<3.0.0'
     state: present
     extra_args: "-c {{ remote_constraints }}"