From d5ac68f5d4038a15544eb108b60d7a3775009014 Mon Sep 17 00:00:00 2001
From: Soren Atmakuri Davidsen <soren@atmakuridavidsen.com>
Date: Sat, 21 Jan 2017 08:29:22 +0100
Subject: [PATCH] initial import.

---
 .gitignore              |   6 ++
 .python-version         |   1 +
 .travis.yml             |   7 ++
 LICENSE                 |  21 ++++++
 README.md               |  50 +++++++++++++
 setup.py                |  20 ++++++
 sshconfig.py            | 156 ++++++++++++++++++++++++++++++++++++++++
 tests/test_config       |  13 ++++
 tests/test_sshconfig.py | 103 ++++++++++++++++++++++++++
 9 files changed, 377 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 .python-version
 create mode 100644 .travis.yml
 create mode 100644 LICENSE
 create mode 100644 README.md
 create mode 100755 setup.py
 create mode 100644 sshconfig.py
 create mode 100644 tests/test_config
 create mode 100644 tests/test_sshconfig.py

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e9676cc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+*.pyc
+__pycache__
+venv
+.cache
+*.egg-info
+.eggs
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..7fc0361
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+sshconfig
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..a1fa5cf
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,7 @@
+language: python
+python:
+  - "2.7"
+install:
+  - python setup.py install
+script:
+  - "py.test ."
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..093f172
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2017 Søren A. Davidsen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c6c7c7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,50 @@
+
+sshconfig
+===========
+
+Sshconfig is a library for reading and modifying your ssh/config file in a non-intrusive way, meaning
+your file should look more or less the same after modifications. Idea is to keep it simple,
+so you can modify it for your needs.
+
+Read more about ssh config files here: [Create SSH config file on Linux](https://www.cyberciti.biz/faq/create-ssh-config-file-on-linux-unix/)
+
+
+Installation and usage
+---------------------------
+
+Install through pip is the most easy way. You can install from the Git source directly:
+
+    pip install -e https://github.com/sorend/sshconfig.git
+
+Below is some example use:
+
+    from __future__ import print_function
+    from sshconfig import read_ssh_config, empty_ssh_config
+    from os.path import expanduser
+
+    c = read_ssh_config(expanduser("~/.ssh/config"))
+    print("hosts", c.hosts())
+
+    # assuming you have a host "svu"
+    print("svu host", c.host("svu"))  # print the settings
+    c.update("svu", Hostname="ssh.svu.local", Port=1234)
+    print("svu host now", c.host("svu"))
+
+    c.add("newsvu", Hostname="ssh-new.svu.local", Port=22, User="stud1234")
+    print("newsvu", c.host("newsvu"))
+
+    c.rename("newsvu", "svu-new")
+    print("svu-new", c.host("svu-new"))
+
+    c.write(expanduser("~/.ssh/newconfig"))  # write to new file
+
+    # creating a new config file.
+    c2 = empty_ssh_config()
+    c2.add("svu", Hostname="ssh.svu.local", User="teachmca", Port=22)
+    c2.write("newconfig")
+
+
+About
+-----
+
+sshconfig is created at the Department of Computer Science at Sri Venkateswara University, Tirupati, INDIA by a student as part of his projects.
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..d4e8779
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from setuptools import setup
+
+MY_VERSION = '0.0.1'
+
+setup(
+    name='sshconfig',
+    version=MY_VERSION,
+    description='Lightweight SSH config library',
+    author='Søren Atmakuri Davidsen',
+    author_email='sorend@cs.svuni.in',
+    url='https://github.com/sorend/sshconfig',
+    download_url='https://github.com/sorend/sshconfig/tarball/%s' % (MY_VERSION,),
+    license='MIT',
+    keywords=['ssh', 'config'],
+    install_requires=[
+    ],
+    setup_requires=['pytest-runner'],
+    tests_require=['pytest'],
+)
diff --git a/sshconfig.py b/sshconfig.py
new file mode 100644
index 0000000..6c161bf
--- /dev/null
+++ b/sshconfig.py
@@ -0,0 +1,156 @@
+
+import re
+
+def read_ssh_config(path):
+    """
+    Read ssh config file and return parsed SshConfig
+    """
+    with open(path, "r") as f:
+        lines = f.read().splitlines()
+    return SshConfig(lines)
+
+def empty_ssh_config():
+    """
+    Creates a new empty ssh configuration.
+    """
+    return SshConfig([])
+
+def _key_value(line):
+    no_comment = line.split("#")[0]
+    return [ x.strip() for x in re.split(r"\s+", no_comment.strip(), 1) ]
+
+class SshConfig(object):
+    """
+    Class for manipulating SSH configuration.
+    """
+    def __init__(self, lines):
+        self.lines_ = []
+        self.hosts_ = set()
+        self.parse(lines)
+
+    def parse(self, lines):
+        cur_entry = None
+        for line in lines:
+            kv = _key_value(line)
+            if len(kv) > 1:
+                k, v = kv
+                if k == "Host":
+                    cur_entry = v
+                    self.hosts_.add(v)
+                self.lines_.append(dict(line=line, host=cur_entry, key=k, value=v))
+            else:
+                self.lines_.append(dict(line=line, host=None))
+
+    def hosts(self):
+        """
+        Return the hosts found in the configuration.
+
+        Returns
+        -------
+        Tuple of Host entries (including "*" if found)
+        """
+        return tuple(self.hosts_)
+
+    def host(self, host):
+        """
+        Return the configuration of a specific host as a dictionary.
+
+        Parameters
+        ----------
+        host : the host to return values for.
+
+        Returns
+        -------
+        dict of key value pairs, excluding "Host"
+        """
+        if host in self.hosts_:
+            return { k: v for k, v in [ (x["key"], x["value"]) for x in self.lines_
+                                        if x["host"] == host and x["key"] != "Host" ]}
+        else:
+            return {}
+
+    def update(self, host, **kwargs):
+        """
+        Update configuration for an existing host. Note, you can update the "Host" value, but
+        it will still be referred to by the old "Host" value.
+
+        Parameters
+        ----------
+        host : the Host to modify.
+        **kwargs : The new configuration parameters
+        """
+        if host not in self.hosts_:
+            raise ValueError("Host %s: not found." % host)
+
+        if "Host" in kwargs:
+            raise ValueError("Cannot modify Host value with update, use rename.")
+
+        def update_line(k, v):
+            return "\t%s\t%s" % (k, v)
+
+        for key, value in kwargs.items():
+            found = False
+            for line in self.lines_:
+                if line["host"] == host and line["key"] == key:
+                    line["value"] = value
+                    line["line"] = update_line(key, value)
+                    found = True
+
+            if not found:
+                max_idx = max([ idx for idx, line in enumerate(self.lines_) if line["host"] == host ])
+                self.lines_.insert(max_idx + 1, dict(line=update_line(key, value),
+                                                     host=host, key=key, value=value))
+
+    def rename(self, old_host, new_host):
+        """
+        Renames a host configuration.
+
+        Parameters
+        ----------
+        old_host : the host to rename.
+        new_host : the new host value
+        """
+        if new_host in self.hosts_:
+            raise ValueError("Host %s: already exists." % new_host)
+        for line in self.lines_:  # update lines
+            if line["host"] == old_host:
+                line["host"] = new_host
+                if line["key"] == "Host":
+                    line["value"] = new_host
+                    line["line"] = "Host\t%s" % new_host
+        self.hosts_.remove(old_host)  # update host cache
+        self.hosts_.add(new_host)
+
+    def add(self, host, **kwargs):
+        """
+        Add another host to the SSH configuration.
+
+        Parameters
+        ----------
+        host: The Host entry to add.
+        **kwargs: The parameters for the host (without "Host" parameter itself)
+        """
+        if host in self.hosts_:
+            raise ValueError("Host %s: exists (use update)." % host)
+        self.hosts_.add(host)
+        self.lines_.append(dict(line="", host=None))
+        self.lines_.append(dict(line="Host\t%s" % host, host=host, key="Host", value=host))
+        for k, v in kwargs.items():
+            self.lines_.append(dict(line="\t%s\t%s" % (k, str(v)), host=host, key=k, value=v))
+
+    def config(self):
+        """
+        Return the configuration as a string.
+        """
+        return "\n".join([ x["line"] for x in self.lines_ ])
+
+    def write(self, path):
+        """
+        Writes ssh config file
+
+        Parameters
+        ----------
+        path : The file to write to
+        """
+        with open(path, "w") as f:
+            f.write(self.config())
diff --git a/tests/test_config b/tests/test_config
new file mode 100644
index 0000000..0b91d14
--- /dev/null
+++ b/tests/test_config
@@ -0,0 +1,13 @@
+
+# comment
+Host *
+     User       something
+
+# comment 2
+Host svu
+     Hostname   www.svuniversity.ac.in
+     Port       22
+     ProxyCommand       nc -w 300 -x localhost:9050 %h %p
+
+# another comment
+# bla bla
diff --git a/tests/test_sshconfig.py b/tests/test_sshconfig.py
new file mode 100644
index 0000000..15f4aa8
--- /dev/null
+++ b/tests/test_sshconfig.py
@@ -0,0 +1,103 @@
+from __future__ import print_function
+import sshconfig
+import pytest
+import os
+
+test_config = os.path.join(os.path.dirname(__file__), "test_config")
+
+def test_parsing():
+    c = sshconfig.read_ssh_config(test_config)
+    assert len(c.hosts()) == 2
+    assert c.host("*")["User"] == "something"
+    assert c.host("svu")["ProxyCommand"] == "nc -w 300 -x localhost:9050 %h %p"
+
+    s1 = c.config().splitlines()
+    s2 = open(test_config).readlines()
+    assert len(s1) == len(s2)
+
+def test_update():
+    c = sshconfig.read_ssh_config(test_config)
+
+    c.update("svu", Compression="no", Port=2222)
+    assert "\tCompression\tno" in c.config()
+    assert "\tPort\t2222" in c.config()
+
+def test_update_host_failed():
+    c = sshconfig.read_ssh_config(test_config)
+
+    with pytest.raises(ValueError):
+        c.update("svu", Host="svu-new")
+
+def test_rename():
+
+    c = sshconfig.read_ssh_config(test_config)
+
+    assert c.host("svu")["Hostname"] == "www.svuniversity.ac.in"
+
+    c.rename("svu", "svu-new")
+
+    assert "Host\tsvu-new" in c.config()
+    assert "Host\tsvu\n" not in c.config()
+    assert "svu" not in c.hosts()
+    assert "svu-new" in c.hosts()
+
+    c.update("svu-new", Port=123)  # has to be success
+    assert c.host("svu-new")["Port"] == 123
+    assert c.host("svu-new")["Hostname"] == "www.svuniversity.ac.in"  # still same
+
+    with pytest.raises(ValueError):  # we can't refer to the renamed host
+        c.update("svu", Port=123)
+
+def test_update_fail():
+    c = sshconfig.read_ssh_config(test_config)
+
+    with pytest.raises(ValueError):
+        c.update("notfound", Port=1234)
+
+def test_add():
+
+    c = sshconfig.read_ssh_config(test_config)
+
+    c.add("venkateswara", Hostname="venkateswara.onion", User="other", Port=22,
+          ProxyCommand="nc -w 300 -x localhost:9050 %h %p")
+
+    assert "venkateswara" in c.hosts()
+    assert c.host("venkateswara")["ProxyCommand"] == "nc -w 300 -x localhost:9050 %h %p"
+
+    assert "Host\tvenkateswara" in c.config()
+
+    with pytest.raises(ValueError):
+        c.add("svu")
+
+    with pytest.raises(ValueError):
+        c.add("venkateswara")
+
+def test_save():
+    import tempfile
+
+    tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-4123")
+    try:
+        c = sshconfig.read_ssh_config(test_config)
+
+        c.update("svu", Hostname="ssh.svuniversity.ac.in", User="mca")
+        c.write(tc)
+
+        c2 = sshconfig.read_ssh_config(tc)
+        assert c2.host("svu")["Hostname"] == "ssh.svuniversity.ac.in"
+        assert c2.host("svu")["User"] == "mca"
+
+    finally:
+        os.remove(tc)
+
+def test_empty():
+    import tempfile
+    tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-123")
+    try:
+        c = sshconfig.empty_ssh_config()
+        c.add("svu33", Hostname="ssh33.svu.local", User="mca", Port=22)
+        c.write(tc)
+        c2 = sshconfig.read_ssh_config(tc)
+        assert 1 == len(c2.hosts())
+        assert c2.host("svu33")["Hostname"] == "ssh33.svu.local"
+    finally:
+        os.remove(tc)